In Java, all classes inherit from the "java.lang.Object" class. Among the inherited methods is the "equals" method. Correct implementation of this method is crucial for the correctness of the program and - contrary to appearances - not necessarily trivial. Many data structures rely on its correct implementation. Therefore, a wrong implementation of it results in their incorrect behavior.
The java.lang.Object.equals method
The equals
method allows you to determine whether two objects are equal. Its default definition provided by the Object
class is based on object references. Such an implementation is sufficient in many cases. In general, for classes whose purpose is to provide some functionality, the equals
method is unlikely to be implemented. An example of this is the HTTP client. It's hard to imagine at all how such objects could be compared other than by identity. It is in such cases that you should rely on the default implementation. The situation is different for objects representing entities from the modeled world, such as objects representing books, notes, etc. It is for these types of objects that the equals
method is generally provided.
The correctness of the implementation of the title method can be considered in two ways:
- Objects should be equal to each other when in the modeled world they would be equal. For example, we can consider two books as equal if they have the same ISBN number.
- The
equals
method must meet the so-called contracts, which are required by the Java language standard and whose observance is necessary for the correct behavior of certain data structures.
Before we move on to analyze the aforementioned contracts, let's take a look at the simple class hierarchy we will refer to next.
1class Book { 2 String isbn; 3} 4 5class Ebook extends Book { 6 String format; 7}
Contracts against equals
The language standard requires equals implementations to maintain the following invariants:
- maneuverability, i.e., the object is equal to itself. In other words, for any object o, it is true that
o.equals(o) == true
, - symmetry, that is, if the first object is equal to the second, then the second object is also equal to the first. This means that if
o1.equals(o2)
returns true (false) theno2.equals(o1)
must also return true (false), - consistency, that is, for any two objects, the
o1.equals(o2)
method should always return the same value, as long as no changes have occurred in the objects, - transitivity is a condition that ensures that the result of the
equals
operation is transitive, i.e. if we have three objectso1
,o2
,o3
, and if o1 is equal too2
ando2
is equal too3
theno1
is equal too3
, - comparing an object and a
null
value always returnsfalse
.
Correct implementation of the equals method
Let's now focus on the correct implementation of the equals
method, i.e. one that preserves all the objections introduced by the language standard. In general, if we consider comparing objects of exactly the same type, the situation is simple and standard implementations, based on comparing object fields, are correct and sufficient. However, the situation is not so simple, because equals
takes a parameter of type Object
in its arguments:
1public boolean equals(Object o)
Consequently, an object of any other type can be compared to an instance of our class. And, while it is obvious that objects from different hierarchies of classes are simply different, the equality of objects remaining in the same hierarchy can already be considered.
Let's now focus on the classes presented above - the book and the ebook. Let's establish that we would like a situation in which ebook and book can be equal. Let's consider a simple implementation:
1class Book { 2 public boolean equals(Object o){ 3 if(!(o instanceof Book)) { 4 return false; 5 } 6 return this.isbn == ((Book)o).isbn; 7 } 8}
This implementation recognizes that two books (and their derivatives - ebooks) are equal when their ISBN numbers are equal. A reasonable implementation for the Ebook
class could look like this:
1class Ebook { 2 public boolean equals(Object o) { 3 if ((o instanceof Ebook)) { 4 return format.equals(((Ebook) o).format) && super.equals(o); 5 } else if ((o instanceof Book)) { 6 return super.equals(o); 7 } 8 return false; 9 } 10}
The implementation of Ebook.equals
considers two cases:
- The object being compared has the type
Ebook
. The situation is simple - we are comparing objects of the same type. - We compare an instance of Ebook with an instance of
Book
. To do this, we call a method from the superclass to compare the part that can be compared - only the ISBN code.
It's not hard to see that both methods provide maneuverability, symmetry and consistency. However, let's look at how the situation looks with transitivity. Well, equals
implemented in this way is not transitive. To see this, just analyze the following case:
1Book b1 = new Book("1"); 2Ebook e1 = new Ebook("1", "mobi"), e2 = new Ebook("1", "epub"); 3e1.equals(b1) -> true (1) 4b1.equals(e2) -> true (2) 5e1.equals(e2) -> false (3)
As we can see, operation (3) returns false
, contrary to what would be expected from a transitive operator.
What about this transitive?
Note that an immediate, more general conclusion can be drawn from the example presented. Namely, that it is impossible to implement an equals
method that would involve comparing objects in a parent-child relationship and at the same time be transitive. This state of affairs is not due to the limitations of the language, but is simply a direct consequence of inheritance. Well, the class that is higher in the class hierarchy has no idea about the fields that are in the class that is lower in the hierarchy. In our example, the book only knows about the ISBN number and can at most expect this number in the derived classes. In other words, when comparing a book and an ebook, there must be so-called logical object sliceing, i.e. treating the ebook as if it were an ordinary book. This is - let it ring out again - a natural consequence of comparing entities of different types, both in the real world and object world sense.
How then can such a problem be solved? In such a case, two things can be done:
- Prevent inheritance of classes that are so-called value classes (classes representing values or real world objects). As a matter of fact, this is quite reasonable both from the point of view of real world modeling and from the technical point of view - such a procedure greatly simplifies the implementation of
equals
. - If for some reason our class has to be open to possible inheritance, we can consider that objects of different types are never equal to each other. Then the implementation is also very simple. The method template with this approach could look like this:
1public boolean equals(Object o) { 2 if(o == null) return false; 3 if(o.getClass() != this.getClass()) return false; 4 ... // just compare fields 5}
In this approach, note that such a method does not behave correctly for derived classes.
Let's go back to the source problem for a moment. Is it really impossible to implement the equals
method so that it meets all the requirements and at the same time is able to compare objects from different levels in the hierarchy? In general, there are techniques that allow for a correct implementation. But then the question still remains, is it really reasonable that objects of different types can be equals? Secondly, such solutions are most often complicated and much more difficult to implement than one would expect from equals
. As a result, however, it seems to make the most sense to consider that value-carrying classes should be classes that are closed to extension.
Summary
The implementation of the java equals
method seems to be very simple. However, special attention should be paid to its proper implementation, as failure to meet its requirements can cause errors that will not necessarily be apparent at first glance. A programmer providing an java equals
implementation should carefully analyze whether his function adheres to all the required strictures.